"use client"; /** * /w/:workspace/notifications — the in-app notification inbox (TASK-0094 R5). * * Live: the list refetches on the `notification.push` frame (via * useNotifications' invalidation) + the mark-read mutations. Deep links * resolve the stored `link` `{type, id, pageId?}` → the page / database / * record page route, then mark the row read (the badge decrements). Two * sections (Unread (n) / Read), `role=list`, the "You're all caught up" * empty state. */ import { useMemo, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { AtSign, Bell, CheckCheck, Database, Link2, Loader2, MessageSquare, PersonStanding, Puzzle, Workflow } from "lucide-react"; import { cn, formatRelativeTime } from "@/lib/utils"; import { notificationApi } from "@/lib/api"; import { useNotifications, useNotificationList } from "@/hooks/use-notifications"; import { useWorkspace } from "@/hooks/use-workspace"; import { UserAvatar } from "@/components/database/cell-renderer"; import type { Notification, NotificationLink } from "@/lib/types"; /** The 8 NotificationType values → the row icon. */ const TYPE_ICON: Record> = { MENTION: AtSign, COMMENT: MessageSquare, ASSIGNMENT: PersonStanding, PAGE_SHARED: Link2, TASK_ASSIGNED: CheckCheck, DATABASE_CHANGE: Database, AUTOMATION: Workflow, INTEGRATION: Puzzle }; /** * Resolve a notification's deep link to a route (or null = not navigable * from the inbox — e.g. a comment/record link written before the `pageId` * ride-along existed). `pageId` is the ride-along the API added (TASK-0094 * R5, recorded) so a comment/record link opens its page directly. */ function resolveNotificationHref( link: NotificationLink | null | undefined, workspaceSlug: string ): string | null { if (!link) return null; switch (link.type) { case "page": return `/w/${workspaceSlug}/page/${link.id}`; case "database": return `/w/${workspaceSlug}/databases/${link.id}`; case "record": case "comment": // The comment/record lives on a page (records are 1:1 pages) — the // `pageId` ride-along points there. Without it (old rows) the row is // not navigable from the inbox (recorded). return link.pageId ? `/w/${workspaceSlug}/page/${link.pageId}` : null; default: return null; } } export default function NotificationsPage() { const params = useParams<{ workspace: string }>(); const workspaceSlug = params?.workspace ?? ""; const { data: workspace } = useWorkspace(workspaceSlug); const router = useRouter(); const qc = useQueryClient(); const [unreadOnly, setUnreadOnly] = useState(false); // The list (live via useNotifications' push invalidation) + the bell count // (shared key — the topbar bell and this page stay in sync). const { data: all = [] } = useNotificationList(); const { invalidate, unreadCount } = useNotifications(); const wsSlug = workspace?.slug || workspaceSlug; const items = useMemo( () => (unreadOnly ? all.filter((n) => n.readAt === null) : all), [all, unreadOnly] ); const unread = useMemo(() => items.filter((n) => n.readAt === null), [items]); const read = useMemo( () => items.filter((n) => n.readAt !== null).slice(0, 50), [items] ); const markRead = useMutation({ mutationFn: (id: string) => notificationApi.markRead(id), onSuccess: () => { qc.invalidateQueries({ queryKey: ["notifications", "list"] }); invalidate(); } }); const markAllRead = useMutation({ mutationFn: () => notificationApi.markAllRead(), onSuccess: () => { qc.invalidateQueries({ queryKey: ["notifications", "list"] }); invalidate(); } }); const openItem = (n: Notification) => { const href = resolveNotificationHref(n.link, wsSlug); if (n.readAt === null) markRead.mutate(n.id); if (href) router.push(href); }; const renderRow = (n: Notification, dimmed: boolean) => { const Icon = TYPE_ICON[n.type] ?? Bell; const href = resolveNotificationHref(n.link, wsSlug); const unreadRow = n.readAt === null; return (
  • {n.actor ? ( ) : ( )}

    {n.actor ? ( {n.actor.name} · ) : null} {n.title}

    {n.body ? (

    {n.body}

    ) : null}

    {formatRelativeTime(n.createdAt)}

    {unreadRow && !dimmed ? ( ) : null} {href ? ( ) : null}
  • ); }; return (

    Notifications

    {all.length === 0 ? (

    You're all caught up.

    ) : (
    {unread.length > 0 && (

    Unread ({unread.length})

      {unread.map((n) => renderRow(n, false))}
    )} {read.length > 0 && (

    Read

      {read.map((n) => renderRow(n, true))}
    )} {unreadOnly && unread.length === 0 && all.length > 0 && (

    No unread notifications.

    )}
    )}
    ); }